昨天,我們完成了 Boss 的實作,今天我們要來添加一些遊戲的收尾工作,讓整個遊戲更加完整,包括計時器、結算畫面以及音效。
Timer:計時器我打算在遊戲畫面的正上方顯示一個計時器,時間格式為 HH:MM:SS,會隨著遊戲時間的增加而更新,並且會隨著遊戲的暫停與恢復而停止與繼續。
import { Game } from '../Game';
export class Timer extends PIXI.Container {
    // 時間文字顯示
    private _timeText: PIXI.Text;
    constructor(private _game: Game) {
        super();
        // 創建時間文字顯示
        const timeText = this._timeText = new PIXI.Text({
            text: "00:00:00",
            style: {
                fontFamily: "Arial",
                fontSize: 20,
                fill: 0xFFFFFF,
                fontWeight: "bold",
                stroke: {
                    color: 0x000000,
                    width: 3
                }
            },
            anchor: 0.5,
            resolution: 2
        } as PIXI.TextOptions);
        this.addChild(timeText);
    }
    /**
     * 更新時間顯示
     */
    update(): void {
        this._updateDisplay(this._game.elapsedTime);
    }
    /**
     * 更新顯示文字
     * @param elapsedTime - 已經過的時間(毫秒)
     */
    private _updateDisplay(elapsedTime: number): void {
        const totalSeconds = Math.floor(elapsedTime / 1000);
        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = totalSeconds % 60;
        const timeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
        this._timeText.text = timeString;
    }
}
這是一個相對簡單的計時器,它完全基於 Game 的 elapsedTime 屬性來更新顯示的時間,並且會在 update() 函數中被呼叫。
雖然不是一個很通用的計時器,但由於我們的遊戲目前只需要這樣的功能,因此採用了這種最簡單的實作方式。
TweenUtil:補間動畫小工具在開始製作結算畫面以前,先來介紹一下我自己在開發專案的時候,基本上一定會自己做的小工具,因為我實在是懶得每一個 Tween 都還要寫一次 new、onComplete、start 之類的東西。雖然現在才補可能有點晚了,但還是稍微介紹一下。
/** 補間動畫小工具 */
export class TweenUtil {
  /**
   * 啟動補間動畫,並返回一個在動畫完成時解析的 Promise。
   * @param tween - 補間動畫
   */
  static start(tween: TWEEN.Tween<any>): Promise<void> {
    return new Promise((resolve, reject) => {
      tween["_onUpdateError"] = reject;
      tween.onComplete(resolve).start();
    })
  }
  /**
   * 啟動補間動畫,並返回一個在動畫完成時解析的 Promise。
   * @param obj            - 要補間的物件
   * @param to             - 目標屬性值
   * @param duration       - 補間持續時間
   * @param easingFunction - 補間緩動函數
   */
  static to<T>(obj: T, to: Partial<T>, duration: number, easingFunction = TWEEN.Easing.Linear.None) {
    return this.start(new TWEEN.Tween(obj).to(to, duration).easing(easingFunction));
  }
}
其實 Day 14 就有類似的東西,基本上我就是把 Tween 與 Promise 結合,並且把執行簡化成兩個函數:
start():較為通用的執行函數,可以塞入任意 Tween 物件,即可執行並等待結束。to():更快速的執行函數,直接傳入物件、屬性、動畫時間即可執行,甚至可以帶入 easing 函數,大大簡化了我使用 Tween 的成本。與 Promise 結合的好處,就是我們可以隨意地在 async(非同步)函數裡面等待。實際上我自己還會再添加 from、fromTo 之類的函數,但這邊就不弄得那麼複雜了。
GameOverLayer:結算畫面當小女巫或 Boss 死亡時,我們要暫停遊戲,並且顯示一個結算畫面,告訴玩家遊戲結束了,並且顯示一些統計數據,如總時間、擊敗敵人數量、獲得經驗值等。
先說說結算畫面的設計,讓大家能夠幻想一下:
import { Game } from "../Game";
import pixi = CG.Pixi.pixi;
import { TweenUtil } from './../../Utils/TweenUtil';
/** 結算畫面 */
export class GameOverLayer extends PIXI.Container {
    private _blackMask: PIXI.Graphics;
    private _resultDialog: PIXI.Container;
    private _titleText: PIXI.Text;
    private _subtitleText: PIXI.Text;
    private _timeText: PIXI.Text;
    private _enemiesDefeatedText: PIXI.Text;
    private _playerLevelText: PIXI.Text;
    private _restartButton: PIXI.Container;
    private _mainMenuButton: PIXI.Container;
    private _isAnimating: boolean = false;
    private _game: Game;
    constructor() {
        super();
        // ... (各種初始化顯示物件、排版,暫且省略)
        // 重新開始遊戲按鈕
        const restartButton = this._restartButton = this._createButton("重新開始", () => {
            this._game?.emit(Game.EVENT.RESTART);
            location.reload(); // 暫時先讓網頁重新整理
        });
        restartButton.position.set(-110, 170);
        resultDialog.addChild(restartButton);
        // 返回主選單按鈕
        const mainMenuButton = this._mainMenuButton = this._createButton("返回主頁", () => {
            this._game?.emit(Game.EVENT.BACK);
            location.reload(); // 暫時先讓網頁重新整理
        });
        mainMenuButton.position.set(110, 170);
        resultDialog.addChild(mainMenuButton);
        // 預設隱藏結算畫面
        this.visible = false;
    }
    /**
     * 創建按鈕。
     * @param label   - 按鈕文字
     * @param onClick - 按鈕點擊事件處理函數
     */
    private _createButton(label: string, onClick: () => void): PIXI.Container {
        const button = new PIXI.Container();
        // ... (各種初始化顯示物件、排版,暫且省略)
        
        button.on("pointertap", () => {
            if (this._isAnimating) return;
            onClick();
        });
        const onPointerOver = () => {
            button.scale.set(1.05);
        };
        button.on("pointerover", onPointerOver);
        button.on("pointerup", onPointerOver);
        const onPointerDown = () => {
            button.scale.set(0.98);
        };
        button.on("pointerdown", onPointerDown);
        const onPointerOut = () => {
            button.scale.set(1);
        };
        button.on("pointerout", onPointerOut);
        button.on("pointerupoutside", onPointerOut);
        return button;
    }
    /**
     * 更新遊戲統計資訊
     * @param game - 遊戲實例
     * @param isVictory - 是否為勝利結束
     */
    private _updateGameStats(game: Game, isVictory: boolean = false): void {
        // 更新遊戲時間
        const elapsedSeconds = Math.floor(game.elapsedTime / 1000);
        const hours = Math.floor(elapsedSeconds / 3600);
        const minutes = Math.floor((elapsedSeconds % 3600) / 60);
        const seconds = elapsedSeconds % 60;
        const timeString = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
        this._timeText.text = `遊戲時間:${timeString}`;
        // 更新擊敗敵人數量(暫時設為 0,之後可以從遊戲統計中獲取)
        this._enemiesDefeatedText.text = `擊敗敵人數量:${game.enemiesDefeated}`;
        // 更新玩家等級與經驗值
        const witch = game.witch;
        this._playerLevelText.text = `玩家等級:${witch.level} (經驗值:${witch.exp}/${witch.maxExp})`;
        // 根據遊戲結果更新標題
        this._titleText.text = isVictory ? "遊戲勝利" : "遊戲結束";
        this._subtitleText.text = isVictory ? "成功挑戰 BOSS!" : "再...再一局...";
    }
    async open(game: Game, isVictory: boolean = false): Promise<void> {
        if (this._isAnimating) return; // 防止重複開啟動畫
        this._isAnimating = true;
        this._game = game;
        // 更新結算資訊
        this._updateGameStats(game, isVictory);
        // 播放打開動畫
        this._blackMask.alpha = 0;
        this._resultDialog.alpha = 0;
        this._resultDialog.scale.set(0.6);
        this.visible = true;
        await TweenUtil.to(this._blackMask, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out);
        await Promise.all([
            TweenUtil.to(this._resultDialog, { alpha: 1 }, 500, TWEEN.Easing.Cubic.Out),
            TweenUtil.to(this._resultDialog.scale, { x: 1, y: 1 }, 500, TWEEN.Easing.Back.Out)
        ]);
        this._isAnimating = false;
    }
}
/**
 * 快速創建文字物件。
 * @param style    - 文字的樣式
 * @param position - 文字物件的位置
 */
function createText(style: Partial<PIXI.TextStyleOptions>, position: PIXI.PointData = { x: 0, y: 0 }): PIXI.Text {
    return new PIXI.Text({
        style, position,
        anchor: 0.5,
        resolution: 2,
    } as PIXI.TextOptions);
}
_createButton() 函數來創建按鈕,並且添加一些預設的互動效果,如縮放、點擊等。_updateGameStats() 函數來更新顯示的遊戲統計資訊。open() 函數來顯示結算畫面,並且播放淡入與縮放動畫。createText() 函數來快速創建文字物件,避免重複的程式碼。Game 的 RESTART 或 BACK 事件,目前暫時是讓網頁重新整理,明天會改成更完整的流程。
GameUILayer:整合計時器與結算畫面有了計時器與結算畫面後,我們需要將它們整合到 GameUILayer 裡面。這邊就不貼初始化的部分了,主要展示它們是如何被使用的。
// GameUILayer.ts
/**
 * 更新 UI 元件
 */
update(): void {
    this._timer.update();
}
/**
 * 顯示遊戲結束畫面
 * @param isVictory - 是否為勝利結束
 */
async showGameOver(isVictory: boolean = false): Promise<void> {
    await this._gameOverLayer.open(this._game, isVictory);
}
update() 函數裡面呼叫計時器的 update() 函數來更新顯示的時間。showGameOver() 函數來顯示結算畫面,Game 裡面會監聽小女巫與 Boss 的 DEATH_END 事件,並且呼叫這個函數來顯示結算畫面。
SoundManager:音訊管理器最後,是時候來添加音效了!雖然 pixi.assets.playSound() 就可以播放音效了,但一個遊戲可不能讓音效亂七八糟地播放,因此我們得來實作一個簡單的音訊管理器,來統一管理音效與音樂的播放、音量等。
import pixi = CG.Pixi.pixi;
/** 音訊管理器 */
export class SoundManager {
	private _sfxVolume: number = 1;
	private _musicVolume: number = 0.5;
	private _sfxInstances: PIXI.sound.IMediaInstance[] = [];
	private _currMusic: PIXI.sound.IMediaInstance;
	// 音效音量 (0 ~ 1)
	get sfxVolume(): number { return this._sfxVolume }
	set sfxVolume(value: number) {
		this._sfxVolume = Math.min(Math.max(value, 0), 1);
		this._sfxInstances.forEach(sfx => {
			sfx.volume = (sfx["originalVolume"] ?? 1) * this._sfxVolume;
		});
	}
	// 音樂音量 (0 ~ 1)
	get musicVolume(): number { return this._musicVolume }
	set musicVolume(value: number) {
		this._musicVolume = Math.min(Math.max(value, 0), 1);
		const music = this._currMusic;
		if (music) music.volume = (music["originalVolume"] ?? 1) * this._musicVolume;
	}
	// 當前音樂
	get currMusic(): PIXI.sound.IMediaInstance { return this._currMusic; }
	private _playSfx(alias: string, options?: Partial<{ filename: string; } & PIXI.sound.PlayOptions>): PIXI.sound.IMediaInstance {
		const originalVolume = options?.volume ?? 1;
		options.volume = originalVolume * this._sfxVolume;
		const sfx = pixi.assets.playSound(alias, options);
		sfx["originalVolume"] = originalVolume;
		this._sfxInstances.push(sfx);
		sfx.once("end", () => {
			const index = this._sfxInstances.indexOf(sfx);
			if (index !== -1) this._sfxInstances.splice(index, 1);
		});
		return sfx;
	}
	private _playMusic(alias: string, options?: Partial<{ filename: string; fadeIn: boolean; } & PIXI.sound.PlayOptions>): PIXI.sound.IMediaInstance {
		if (this._currMusic) this.fadeOutMusic();
		const originalVolume = options?.volume ?? 1;
		const volume = originalVolume * this._musicVolume;
		const fadeIn = options?.fadeIn ?? false;
		options = {
			...options,
			volume: fadeIn ? 0 : volume,
			loop: true
		};
		const music = this._currMusic = pixi.assets.playSound(alias, options);
		music["originalVolume"] = originalVolume;
		if (fadeIn) {
			new TWEEN.Tween(music)
				.to({ volume: volume }, 300)
				.start();
		}
		return music;
	}
	/**
	 * 播放音效。
	 * @param filename - 音效檔名
	 * @param volume   - 音效音量 (0 ~ 1)
	 */
	playSfx(filename: string, volume?: number): void {
		this._playSfx("LittleWitch_TheJourney.音效", { volume: volume, filename: filename });
	}
	/**
	 * 播放音樂,會自動淡出當前正在播放的音樂。
	 * @param filename - 音樂檔名
	 * @param volume   - 音樂音量 (0 ~ 1)
	 */
	playMusic(filename: string, volume?: number): void {
		this._playMusic("LittleWitch_TheJourney.音樂.JuhaniJunkala", { volume: volume, filename: filename, fadeIn: true });
	}
	fadeOutMusic(duration: number = 1000): Promise<void> {
		const music = this._currMusic;
		this._currMusic = null;
		return new Promise<void>(resolve => {
			if (!music) resolve();
			else {
				new TWEEN.Tween(music)
					.to({ volume: 0 }, duration)
					.onComplete(() => {
						music.destroy();
						resolve();
					})
					.start();
			}
		});
	}
}
export const soundManager = new SoundManager();
_playSfx() 函數來播放音效,並且會記錄正在播放的音效實例,方便後續調整音量或停止。_playMusic() 函數來播放音樂,並且會自動淡出當前正在播放的音樂,並且支援淡入效果。fadeOutMusic() 函數來淡出當前音樂,並且在淡出完成後銷毀音樂實例。soundManager 物件,讓所有的地方都有一個統一的管理器可以使用。這是一個簡單,但我認為相當通用的音訊管理器,兩個公用的 playSfx() 與 playMusic() 是針對這個專案添加的。實際上如果把這兩個函數去掉,直接把 _playSfx() 與 _playMusic() 設為公用的話,這個音訊管理器應該就可以用在任何專案了。
你還可以嘗試利用 localStorage 來記錄音量設定,或是添加更多的功能,如靜音、暫停等,但這邊就不多做贅述了。
最後當然就是到處添加音效了,像是小女巫的攻擊、受傷、死亡,敵人的生成、攻擊、受傷、死亡,Boss 的技能等,都可以添加音效來提升遊戲的體驗,這邊就不特別展示了,最後完成的時候我會特別再錄一段影片來展示。
LevelUpLayer 添加了鍵盤的監聽器,除了用滑鼠選擇要升級的選項,也可以用鍵盤上的數字鍵 1、2、3 來進行選擇。這個優化是因為我自己在測試的時候,幾乎都是一隻手放在 WASD 上,另一隻手在發呆,懶得再拿起滑鼠而添加的。今天我們完成了遊戲的收尾工作,讓整個遊戲更加完整與有趣:
到此為止,我們的《小女巫・啟程》遊戲本體已經完成了!明天我會來添加最後的場景切換,讓玩家剛進入遊戲時會看到遊戲封面,之後才會進入遊戲。有餘裕的話,說不定還可以添加開場動畫呢!